D:\a\cssh-rs\cssh-rs\cssh-rs-core\src\lib.rs
Line | Count | Source |
1 | | //! Cross-platform cluster SSH tool |
2 | | |
3 | | #![deny(clippy::implicit_return)] |
4 | | #![allow(clippy::needless_return, clippy::doc_overindented_list_items)] |
5 | | #![warn(missing_docs)] |
6 | | #![doc(html_no_source)] |
7 | | #![cfg_attr(coverage_nightly, feature(coverage_attribute))] |
8 | | |
9 | | use std::fs::{create_dir, File}; |
10 | | use std::mem; |
11 | | |
12 | | use log::warn; |
13 | | use registry::{key, value, Data, Hive, Security}; |
14 | | use simplelog::{format_description, ConfigBuilder, LevelFilter, WriteLogger}; |
15 | | use windows::core::PWSTR; |
16 | | use windows::Win32::Foundation::HWND; |
17 | | use windows::Win32::System::Threading::{PROCESS_INFORMATION, STARTUPINFOW}; |
18 | | |
19 | | #[cfg(test)] |
20 | | use mockall::automock; |
21 | | |
22 | | pub mod cli; |
23 | | pub mod client; |
24 | | pub mod daemon; |
25 | | pub mod utils; |
26 | | |
27 | | use utils::windows::WindowsApi; |
28 | | |
29 | | /// CLSID identifying `conhost.exe` in the registry. |
30 | | /// |
31 | | /// As used in Windows Terminal: |
32 | | /// <https://github.com/microsoft/terminal/blob/v1.22.3232.0/src/propslib/DelegationConfig.hpp#L105> |
33 | | const CLSID_CONHOST: &str = "{B23D10C0-E52E-411E-9D5B-C09FDF709C7D}"; |
34 | | /// Registry path where `DelegationConsole` and `DelegationTerminal` registry keys are stored. |
35 | | /// |
36 | | /// These registry keys store the configuration value for the default terminal application. |
37 | | const DEFAULT_TERMINAL_APP_REGISTRY_PATH: &str = r"Console\%%Startup"; |
38 | | /// `DelegationConsole` registry key. |
39 | | /// |
40 | | /// As used in Windows Terminal: |
41 | | /// <https://github.com/microsoft/terminal/blob/v1.22.3232.0/src/propslib/DelegationConfig.cpp#L29> |
42 | | const DELEGATION_CONSOLE: &str = "DelegationConsole"; |
43 | | /// `DelegationTerminal` registry key. |
44 | | /// |
45 | | /// As used in Windows Terminal: |
46 | | /// <https://github.com/microsoft/terminal/blob/v1.22.3232.0/src/propslib/DelegationConfig.cpp#L30> |
47 | | const DELEGATION_TERMINAL: &str = "DelegationTerminal"; |
48 | | |
49 | | /// Trait for registry operations to enable mocking in tests |
50 | | #[cfg_attr(test, automock)] |
51 | | pub trait Registry { |
52 | | /// Return the string value at `path`/`name`, or `None` if the key or value |
53 | | /// does not exist. |
54 | | fn get_registry_string_value(&self, path: &str, name: &str) -> Option<String>; |
55 | | /// Set a string value, creating the key if it does not exist. Return whether it succeeded. |
56 | | fn set_registry_string_value(&self, path: &str, name: &str, value: &str) -> bool; |
57 | | /// Delete a value; an already-absent value counts as success. |
58 | | fn delete_registry_string_value(&self, path: &str, name: &str) -> bool; |
59 | | /// Return whether the registry key at `path` exists. |
60 | | fn registry_key_exists(&self, path: &str) -> bool; |
61 | | /// Delete the registry key at `path` recursively. Return whether it succeeded. |
62 | | fn delete_registry_key(&self, path: &str) -> bool; |
63 | | } |
64 | | |
65 | | /// Default implementation of Registry trait that performs actual Windows registry API calls |
66 | | pub struct DefaultRegistry; |
67 | | |
68 | | #[cfg_attr(coverage_nightly, coverage(off))] |
69 | | impl Registry for DefaultRegistry { |
70 | | fn get_registry_string_value(&self, path: &str, name: &str) -> Option<String> { |
71 | | let key = Hive::CurrentUser.open(path, Security::Read).ok()?; |
72 | | match key.value(name) { |
73 | | Ok(Data::String(value)) => return Some(value.to_string_lossy()), |
74 | | Ok(_) => panic!("Expected string data for {name} registry value"), |
75 | | Err(value::Error::NotFound(_, _)) => return None, |
76 | | Err(err) => { |
77 | | warn!("Failed to read {} value from registry: {}", name, err); |
78 | | return None; |
79 | | } |
80 | | } |
81 | | } |
82 | | |
83 | | fn set_registry_string_value(&self, path: &str, name: &str, value: &str) -> bool { |
84 | | // create() opens the key or makes it when absent, forcing conhost on a fresh profile. |
85 | | match Hive::CurrentUser.create(path, Security::Read | Security::Write) { |
86 | | Ok(key) => match key.set_value::<String>( |
87 | | name.to_owned(), |
88 | | &Data::String(value.to_owned().try_into().unwrap()), |
89 | | ) { |
90 | | Ok(()) => return true, |
91 | | Err(err) => { |
92 | | warn!( |
93 | | "Failed to set registry value {} to {}: {}", |
94 | | name, value, err |
95 | | ); |
96 | | return false; |
97 | | } |
98 | | }, |
99 | | Err(err) => { |
100 | | warn!("Failed to open or create registry key {}: {}", path, err); |
101 | | return false; |
102 | | } |
103 | | } |
104 | | } |
105 | | |
106 | | fn delete_registry_string_value(&self, path: &str, name: &str) -> bool { |
107 | | let key = match Hive::CurrentUser.open(path, Security::Read | Security::Write) { |
108 | | Ok(key) => key, |
109 | | // A missing key means the value is already absent; any other error |
110 | | // (access denied, transient failure) must not masquerade as success. |
111 | | Err(key::Error::NotFound(_, _)) => return true, |
112 | | Err(err) => { |
113 | | warn!( |
114 | | "Failed to open registry key {} to delete {}: {}", |
115 | | path, name, err |
116 | | ); |
117 | | return false; |
118 | | } |
119 | | }; |
120 | | match key.delete_value(name) { |
121 | | Ok(()) => return true, |
122 | | Err(value::Error::NotFound(_, _)) => return true, |
123 | | Err(err) => { |
124 | | warn!("Failed to delete registry value {}: {}", name, err); |
125 | | return false; |
126 | | } |
127 | | } |
128 | | } |
129 | | |
130 | | fn registry_key_exists(&self, path: &str) -> bool { |
131 | | return Hive::CurrentUser.open(path, Security::Read).is_ok(); |
132 | | } |
133 | | |
134 | | fn delete_registry_key(&self, path: &str) -> bool { |
135 | | match Hive::CurrentUser.delete(path, true) { |
136 | | Ok(()) => return true, |
137 | | Err(err) => { |
138 | | warn!("Failed to delete registry key {}: {}", path, err); |
139 | | return false; |
140 | | } |
141 | | } |
142 | | } |
143 | | } |
144 | | |
145 | | /// Return the Window Handle [HWND] for the foreground window associated with the given `process_id`. |
146 | | /// |
147 | | /// If multiple foreground windows are associated with the given `process_id` it is undefined which [HWND] gets returned. |
148 | | /// |
149 | | /// # Arguments |
150 | | /// |
151 | | /// * `windows_api` - Windows API operations implementation |
152 | | /// * `process_id` - ID of the process for which to retrieve the window handle. |
153 | | /// |
154 | | /// # Returns |
155 | | /// |
156 | | /// The Window Handle [HWND] for the window associated with the given `process_id`. |
157 | 5 | pub fn get_console_window_handle<W: WindowsApi>(windows_api: &W, process_id: u32) -> HWND { |
158 | 5 | return windows_api.get_window_handle_for_process(process_id); |
159 | 5 | } |
160 | | |
161 | | /// Create process with command line using the provided API (testable version) |
162 | | /// |
163 | | /// # Arguments |
164 | | /// |
165 | | /// * `api` - Windows API operations implementation |
166 | | /// * `application` - Application name including file extension |
167 | | /// * `command_line` - UTF-16 encoded command line |
168 | | /// |
169 | | /// # Returns |
170 | | /// |
171 | | /// [PROCESS_INFORMATION] of the spawned process or None if failed |
172 | 3 | pub fn create_process<W: WindowsApi>( |
173 | 3 | api: &W, |
174 | 3 | application: &str, |
175 | 3 | command_line: &[u16], |
176 | 3 | ) -> Option<PROCESS_INFORMATION> { |
177 | 3 | let mut startupinfo = STARTUPINFOW { |
178 | 3 | cb: mem::size_of::<STARTUPINFOW>() as u32, |
179 | 3 | ..Default::default() |
180 | 3 | }; |
181 | 3 | let mut process_information = PROCESS_INFORMATION::default(); |
182 | 3 | let mut cmd_line = command_line.to_vec(); |
183 | 3 | let command_line_ptr = PWSTR(cmd_line.as_mut_ptr()); |
184 | | |
185 | 3 | match api.create_process_raw( |
186 | 3 | application, |
187 | 3 | command_line_ptr, |
188 | 3 | &mut startupinfo, |
189 | 3 | &mut process_information, |
190 | 3 | ) { |
191 | 2 | Ok(()) => return Some(process_information), |
192 | 1 | Err(_) => return None, |
193 | | } |
194 | 3 | } |
195 | | |
196 | | /// Trait for file system operations to enable mocking in tests |
197 | | #[cfg_attr(test, automock)] |
198 | | pub trait FileSystem { |
199 | | /// Create a directory |
200 | | fn create_directory(&self, path: &str) -> bool; |
201 | | /// Create a log file |
202 | | fn create_log_file(&self, filename: &str) -> bool; |
203 | | } |
204 | | |
205 | | /// Default implementation of FileSystem trait that performs actual file system operations |
206 | | pub struct ProductionFileSystem; |
207 | | |
208 | | #[cfg_attr(coverage_nightly, coverage(off))] |
209 | | impl FileSystem for ProductionFileSystem { |
210 | | fn create_directory(&self, path: &str) -> bool { |
211 | | return create_dir(path).is_ok() || std::path::Path::new(path).exists(); |
212 | | } |
213 | | |
214 | | fn create_log_file(&self, filename: &str) -> bool { |
215 | | return File::create(filename).is_ok(); |
216 | | } |
217 | | } |
218 | | |
219 | | /// Guard that configures `conhost.exe` as the default terminal application and |
220 | | /// fully reverts its changes when dropped. |
221 | | /// |
222 | | /// Restoration is exact: values the guard overwrote are set back, values it |
223 | | /// created are deleted, and a startup key the guard had to create is removed. |
224 | | pub struct WindowsSettingsDefaultTerminalApplicationGuard<R: Registry> { |
225 | | /// Whether the guard changed the registry and must undo it on drop. |
226 | | changed: bool, |
227 | | /// Whether the startup key existed before the guard; if not, drop deletes it. |
228 | | key_existed: bool, |
229 | | /// `DelegationConsole` before the guard overwrote it, or `None` if it was absent. |
230 | | old_windows_terminal_console: Option<String>, |
231 | | /// `DelegationTerminal` before the guard overwrote it, or `None` if it was absent. |
232 | | old_windows_terminal_terminal: Option<String>, |
233 | | /// Registry operations trait |
234 | | registry: R, |
235 | | } |
236 | | |
237 | | impl<R: Registry> WindowsSettingsDefaultTerminalApplicationGuard<R> { |
238 | | /// Create a new guard, forcing `conhost.exe` as the default terminal application. |
239 | | /// |
240 | | /// # Arguments |
241 | | /// |
242 | | /// * `registry` - Registry operations implementation |
243 | | /// |
244 | | /// # Returns |
245 | | /// |
246 | | /// A new guard that reverts its registry changes on drop. |
247 | 10 | pub fn new_with_registry(registry: R) -> Self { |
248 | 10 | let key_existed = registry.registry_key_exists(DEFAULT_TERMINAL_APP_REGISTRY_PATH); |
249 | 10 | let old_windows_terminal_console = registry |
250 | 10 | .get_registry_string_value(DEFAULT_TERMINAL_APP_REGISTRY_PATH, DELEGATION_CONSOLE); |
251 | 10 | let old_windows_terminal_terminal = registry |
252 | 10 | .get_registry_string_value(DEFAULT_TERMINAL_APP_REGISTRY_PATH, DELEGATION_TERMINAL); |
253 | | |
254 | 10 | let already_conhost = old_windows_terminal_console.as_deref() == Some(CLSID_CONHOST) |
255 | 3 | && old_windows_terminal_terminal.as_deref() == Some(CLSID_CONHOST); |
256 | | |
257 | 10 | if !already_conhost { |
258 | 9 | registry.set_registry_string_value( |
259 | 9 | DEFAULT_TERMINAL_APP_REGISTRY_PATH, |
260 | 9 | DELEGATION_CONSOLE, |
261 | 9 | CLSID_CONHOST, |
262 | 9 | ); |
263 | 9 | registry.set_registry_string_value( |
264 | 9 | DEFAULT_TERMINAL_APP_REGISTRY_PATH, |
265 | 9 | DELEGATION_TERMINAL, |
266 | 9 | CLSID_CONHOST, |
267 | 9 | ); |
268 | 9 | }1 |
269 | | |
270 | 10 | return WindowsSettingsDefaultTerminalApplicationGuard { |
271 | 10 | changed: !already_conhost, |
272 | 10 | key_existed, |
273 | 10 | old_windows_terminal_console, |
274 | 10 | old_windows_terminal_terminal, |
275 | 10 | registry, |
276 | 10 | }; |
277 | 10 | } |
278 | | |
279 | | /// Restore `name` to its pre-guard value, or delete it if it was absent. |
280 | 16 | fn restore_value(&self, name: &str, previous: &Option<String>) { |
281 | 16 | match previous { |
282 | 4 | Some(value) => { |
283 | 4 | self.registry.set_registry_string_value( |
284 | 4 | DEFAULT_TERMINAL_APP_REGISTRY_PATH, |
285 | 4 | name, |
286 | 4 | value, |
287 | 4 | ); |
288 | 4 | } |
289 | 12 | None => { |
290 | 12 | self.registry |
291 | 12 | .delete_registry_string_value(DEFAULT_TERMINAL_APP_REGISTRY_PATH, name); |
292 | 12 | } |
293 | | }; |
294 | 16 | } |
295 | | } |
296 | | |
297 | | impl WindowsSettingsDefaultTerminalApplicationGuard<DefaultRegistry> { |
298 | | /// Create a new guard with production registry operations |
299 | 6 | pub fn new() -> Self { |
300 | 6 | return Self::new_with_registry(DefaultRegistry); |
301 | 6 | } |
302 | | } |
303 | | |
304 | | impl<R: Registry> Default for WindowsSettingsDefaultTerminalApplicationGuard<R> |
305 | | where |
306 | | R: Default, |
307 | | { |
308 | 0 | fn default() -> Self { |
309 | 0 | return Self::new_with_registry(R::default()); |
310 | 0 | } |
311 | | } |
312 | | |
313 | | impl Default for DefaultRegistry { |
314 | 0 | fn default() -> Self { |
315 | 0 | return DefaultRegistry; |
316 | 0 | } |
317 | | } |
318 | | |
319 | | impl<R: Registry> Drop for WindowsSettingsDefaultTerminalApplicationGuard<R> { |
320 | | /// Revert every registry change the guard made. |
321 | 10 | fn drop(&mut self) { |
322 | 10 | if !self.changed { |
323 | 1 | return; |
324 | 9 | } |
325 | | // The guard created the startup key; deleting it removes the values too. |
326 | 9 | if !self.key_existed { |
327 | 1 | self.registry |
328 | 1 | .delete_registry_key(DEFAULT_TERMINAL_APP_REGISTRY_PATH); |
329 | 1 | return; |
330 | 8 | } |
331 | 8 | self.restore_value(DELEGATION_CONSOLE, &self.old_windows_terminal_console); |
332 | 8 | self.restore_value(DELEGATION_TERMINAL, &self.old_windows_terminal_terminal); |
333 | 10 | } |
334 | | } |
335 | | |
336 | | /// Launch the given console application with the given arguments as a new detached process with its own console window. |
337 | | /// |
338 | | /// Input/Output handles are not being inherited. |
339 | | /// Whichever default terminal application is configured in the windows system settings will be used |
340 | | /// to host the application (i.e. create the window). |
341 | | /// |
342 | | /// # Arguments |
343 | | /// |
344 | | /// * `api` - Windows API implementation |
345 | | /// * `application` - Application name including file extension (`.exe`). |
346 | | /// If the application is not in the `PATH` environment variable, |
347 | | /// the full path must be specified. |
348 | | /// * `args` - List of arguments to the application. |
349 | | /// * `with_keyboard_focus` - Whether the new console window should take foreground focus |
350 | | /// when it appears. Pass `false` when spawning child consoles |
351 | | /// that must not steal focus from the calling process. |
352 | | /// |
353 | | /// # Returns |
354 | | /// |
355 | | /// [PROCESS_INFORMATION] of the spawned process. |
356 | 10 | pub fn spawn_console_process<W: WindowsApi>( |
357 | 10 | api: &W, |
358 | 10 | application: &str, |
359 | 10 | args: Vec<String>, |
360 | 10 | with_keyboard_focus: bool, |
361 | 10 | ) -> Option<PROCESS_INFORMATION> { |
362 | 10 | return api.create_process_with_args(application, args, with_keyboard_focus); |
363 | 10 | } |
364 | | |
365 | | /// Return the path to the currently running executable. |
366 | | /// |
367 | | /// Used when spawning child daemon/client consoles so that they invoke the same |
368 | | /// binary that is currently running, regardless of how the user has named the |
369 | | /// executable on disk. Hard-coding `cssh-rs.exe` would break any deployment that |
370 | | /// renames the binary (e.g. release artifacts that embed the version number). |
371 | | /// |
372 | | /// # Returns |
373 | | /// |
374 | | /// The current executable path as a UTF-8 string. The conversion is lossy if |
375 | | /// the path contains non-UTF-8 code units. |
376 | | /// |
377 | | /// # Panics |
378 | | /// |
379 | | /// Panics if `std::env::current_exe()` fails. The standard library only |
380 | | /// returns an error in highly unusual circumstances (e.g. the executable has |
381 | | /// been deleted while running); the caller cannot meaningfully recover. |
382 | 13 | pub fn current_exe_path() -> String { |
383 | 13 | return std::env::current_exe() |
384 | 13 | .expect("Failed to determine current executable path") |
385 | 13 | .to_string_lossy() |
386 | 13 | .into_owned(); |
387 | 13 | } |
388 | | |
389 | | /// Initialize the logger. |
390 | | /// |
391 | | /// Makes sure a `logs` directory exists in the current working directory. |
392 | | /// Log filename format: `<utc-time-of-executable-start>_<name>.log`. |
393 | | /// Configures [log_panics]. |
394 | | /// |
395 | | /// # Arguments |
396 | | /// |
397 | | /// * `name` - Will be part of the log filename. |
398 | 0 | pub fn init_logger(name: &str) { |
399 | 0 | init_logger_with_fs(&ProductionFileSystem, name); |
400 | 0 | } |
401 | | |
402 | | /// Initialize the logger with the provided file system operations. |
403 | | /// |
404 | | /// # Arguments |
405 | | /// |
406 | | /// * `fs` - File system operations implementation |
407 | | /// * `name` - Will be part of the log filename |
408 | 9 | pub fn init_logger_with_fs<F: FileSystem>(fs: &F, name: &str) { |
409 | 9 | let utc_now = chrono::offset::Utc::now() |
410 | 9 | .format("%Y-%m-%d_%H-%M-%S.%f") |
411 | 9 | .to_string(); |
412 | | |
413 | 9 | fs.create_directory("logs"); |
414 | | |
415 | 9 | let filename = format!("logs/{utc_now}_{name}.log"); |
416 | 9 | if fs.create_log_file(&filename) { |
417 | 7 | if let Ok(file0 ) = File::create(&filename) { |
418 | 0 | let _ = WriteLogger::init( |
419 | 0 | LevelFilter::Debug, |
420 | 0 | ConfigBuilder::new() |
421 | 0 | .set_time_format_custom(format_description!( |
422 | 0 | "[hour]:[minute]:[second].[subsecond]" |
423 | 0 | )) |
424 | 0 | .build(), |
425 | 0 | file, |
426 | 0 | ); |
427 | 0 | log_panics::init(); |
428 | 7 | } |
429 | 2 | } |
430 | 9 | } |
431 | | |
432 | | /// Detect if application was launched from Windows Explorer (GUI) vs command line using the provided console API. |
433 | | /// |
434 | | /// Returns true if launched from GUI (separate console), false if from existing console. |
435 | | /// Based on: <https://devblogs.microsoft.com/oldnewthing/20160125-00/?p=92922> |
436 | | /// |
437 | | /// # Arguments |
438 | | /// |
439 | | /// * `windows_api` - Windows API operations implementation |
440 | | /// |
441 | | /// # Returns |
442 | | /// |
443 | | /// * `true` - Application was launched from GUI (Explorer, double-click, etc.) |
444 | | /// * `false` - Application was launched from existing console (command line) |
445 | 12 | pub fn is_launched_from_gui<W: WindowsApi>(windows_api: &W) -> bool { |
446 | 12 | return windows_api.get_console_attached_process_count() == 1; |
447 | 12 | } |
448 | | |
449 | | #[cfg(test)] |
450 | | #[path = "./tests/test_lib.rs"] |
451 | | mod test_lib; |